fix(nodejs): add CJS compatibility for VS Code extensions#546
fix(nodejs): add CJS compatibility for VS Code extensions#546darthmolen wants to merge 1 commit intogithub:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical CJS compatibility issue (#528) where getBundledCliPath() failed in VS Code extensions bundled with esbuild format:"cjs". The root cause was using import.meta.resolve(), an ESM-only API that becomes undefined in CJS contexts.
Changes:
- Replaced
import.meta.resolve()with acreateRequire()+ path walking approach that works in both ESM and CJS environments - Added CJS build output (
dist/cjs/index.cjs) via esbuild bundling - Updated
package.jsonwith conditionalexportssupporting bothimportandrequireconditions - Added 4 new CJS compatibility tests to verify the fix
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| nodejs/src/client.ts | Refactored getBundledCliPath() to use createRequire() with __filename fallback instead of import.meta.resolve(), making it work in CJS bundles |
| nodejs/package.json | Added conditional exports with separate import (ESM) and require (CJS) entry points, updated main field to point to ESM build |
| nodejs/esbuild-copilotsdk-nodejs.ts | Added CJS build configuration that bundles to dist/cjs/index.cjs with external dependencies marked |
| nodejs/test/cjs-compat.test.ts | New test suite with 4 tests validating CJS build exists, is requireable, exports CopilotClient, and resolves CLI path correctly |
nodejs/test/cjs-compat.test.ts
Outdated
| // Verify that new CopilotClient({ cliUrl: "8080" }) doesn't throw | ||
| // (constructor calls getBundledCliPath() for the default cliPath) | ||
| const script = ` | ||
| const sdk = require(${JSON.stringify(cjsEntryPoint)}); | ||
| // Use cliUrl to avoid actually spawning the CLI, | ||
| // but the constructor still evaluates getBundledCliPath() for cliPath default |
There was a problem hiding this comment.
The test comment states that using cliUrl: "8080" avoids spawning the CLI but "the constructor still evaluates getBundledCliPath() for cliPath default". However, looking at the constructor implementation, when cliUrl is provided, the cliPath is still resolved via getBundledCliPath() even though it won't be used (since isExternalServer will be true).
This test correctly validates that getBundledCliPath() works in a CJS context, but it may not catch scenarios where the @github/copilot package is not installed or available in node_modules, which is common in VS Code extension bundles that don't include node_modules. Consider adding a test case that simulates this scenario or clarifying in the comment that the test assumes @github/copilot is installed in node_modules.
| // Verify that new CopilotClient({ cliUrl: "8080" }) doesn't throw | |
| // (constructor calls getBundledCliPath() for the default cliPath) | |
| const script = ` | |
| const sdk = require(${JSON.stringify(cjsEntryPoint)}); | |
| // Use cliUrl to avoid actually spawning the CLI, | |
| // but the constructor still evaluates getBundledCliPath() for cliPath default | |
| // Verify that new CopilotClient({ cliUrl: "8080" }) doesn't throw. | |
| // In the current implementation, the constructor still resolves a default | |
| // cliPath via getBundledCliPath(), even when cliUrl is provided, so this | |
| // test exercises getBundledCliPath() in a pure CJS context. | |
| // | |
| // NOTE: This test assumes that the bundled @github/copilot CLI package is | |
| // present in node_modules. It does NOT simulate environments (e.g. some | |
| // VS Code extension bundles) where @github/copilot is absent. | |
| const script = ` | |
| const sdk = require(${JSON.stringify(cjsEntryPoint)}); | |
| // Use cliUrl to avoid actually spawning the CLI process; we only care | |
| // that constructing the client (and resolving the bundled CLI path) | |
| // does not throw in a CJS-only environment. |
| const searchPaths = require.resolve.paths("@github/copilot") ?? []; | ||
| for (const base of searchPaths) { | ||
| const candidate = join(base, "@github", "copilot", "index.js"); | ||
| if (existsSync(candidate)) { | ||
| return candidate; | ||
| } | ||
| } | ||
| throw new Error( | ||
| "Could not find @github/copilot package. Ensure it is installed." | ||
| ); | ||
| } |
There was a problem hiding this comment.
The implementation assumes that require.resolve.paths("@github/copilot") will return a non-empty array. However, in some bundled scenarios or when using alternative package managers, this method might return null or an empty array, which would cause the function to throw immediately without providing helpful debugging information.
Consider adding logging or a more descriptive error message that explains what paths were searched. For example:
const searchPaths = require.resolve.paths("@github/copilot") ?? [];
if (searchPaths.length === 0) {
throw new Error(
"Could not determine module resolution paths for @github/copilot. " +
"Ensure the package is installed or provide an explicit cliPath option."
);
}
// ... existing loop ...This would help developers diagnose issues more quickly.
nodejs/esbuild-copilotsdk-nodejs.ts
Outdated
| // Mark dependencies as external — they're resolved at runtime by the consumer | ||
| external: [ | ||
| "vscode-jsonrpc", | ||
| "vscode-jsonrpc/*", | ||
| "zod", | ||
| "@github/copilot", | ||
| "@github/copilot/*", | ||
| ], |
There was a problem hiding this comment.
The CJS build configuration marks @github/copilot and @github/copilot/* as external dependencies, which means they won't be bundled. This is correct since getBundledCliPath() needs to resolve the actual installed package in node_modules at runtime.
However, this creates a potential issue: if a consumer (like a VS Code extension) bundles the SDK and doesn't include @github/copilot in their node_modules at runtime, getBundledCliPath() will fail. The PR description mentions this scenario but doesn't provide a clear solution beyond passing an explicit cliPath.
Consider documenting this requirement prominently in the README or adding a fallback behavior when the package can't be found.
|
Thanks @darthmolen. I was expecting we could produce just a single build output. Supporting both cjs and esm will be an ongoing cost as people would encounter different issues across the two. Do you have a reason to think we need to support both and not just CJS? |
|
Perhaps the best outcome altogether would be having only a single ESM build, but putting in place whatever fallback logic is needed to get |
Replace import.meta.resolve with createRequire + path walking in getBundledCliPath(). The new implementation falls back to __filename when import.meta.url is unavailable (shimmed CJS environments like VS Code extensions bundled with esbuild format:"cjs"). Single ESM build output retained — no dual CJS/ESM builds needed. The fallback logic handles both native ESM and shimmed CJS contexts.
d1d3669 to
fb560ad
Compare
|
@SteveSandersonMS Good call — reworked to your suggestion. What changed: Removed the dual CJS/ESM build entirely. The PR now keeps the single ESM build output unchanged (no modifications to The fix:
Net diff from main: 2 files — |
Summary
Fixes #528 —
getBundledCliPath()usesimport.meta.resolve("@github/copilot/sdk")which throws in CJS contexts (VS Code extensions bundled with esbuildformat:"cjs").Changes:
import.meta.resolvewithcreateRequire+ module resolution path walking (works in both ESM and CJS)dist/cjs/index.cjs) via esbuildexportsin package.json (import+require)Problem
The
@github/copilotpackage only exposes an ESM"import"condition for its./sdksubpath export — no"require"condition. This means:import.meta.resolve("@github/copilot/sdk")— works in ESM onlyrequire.resolve("@github/copilot/sdk")— fails (Package subpath './sdk' is not defined by "exports")require.resolve("@github/copilot/package.json")— also fails (not exported)When a consumer bundles the SDK with esbuild
format:"cjs",import.metabecomes an empty object, andimport.meta.resolveis undefined.Solution
Instead of resolving through the
@github/copilotpackage's strict exports, we:createRequire(import.meta.url ?? pathToFileURL(__filename).href)—import.meta.urlin ESM,__filenamefallback in CJSrequire.resolve.paths("@github/copilot")to get the Node module search directories@github/copilot/index.jsusingexistsSyncThis avoids the ESM-only exports entirely and works in both module systems.
Test plan
CopilotClientCopilotClientconstructor resolves bundled CLI path in CJS contextformat:"cjs", verifygetBundledCliPath()works